No, tole je naslednji mail na temo algoritmov in tehnik, ki pridejo prav pri
resevanju problemov.

Mnogi problemi so take vrste, da iscemo neko najboljso resitev (najboljso
stvar, ki ima doloceno lastnost) in to najboljso resitev gradimo postopoma.
Znotraj problema najdemo nekaj podproblemov, ki so v nekem smislu manjsi,
drugace pa so to problemi istega tipa kot prvotni problem.  Znotraj teh
podproblemov najdemo spet se manjse podpodprobleme in tako naprej; scasoma
pridemo do trivialno majhnih problemov, pri katerih je resitev cisto ocitna.
Ko resimo podprobleme nekega problema, pa njihove resitve skombiniramo v
resitev celega problema.

Vendar pa ima ta pristop (ki bi bil sicer marsikje uporaben) lahko to
slabost, da nam nastane prevec podproblemov.  Recimo, da imamo nek problem
velikosti n (karkoli ze ta velikost v konkretnem primeru pomeni) in da
najdemo znotraj njega recimo n podproblemov velikosti n-1; znotraj vsakega
od teh najdemo n-1 podproblemov velikosti n-2; itd., itd.  Trivialnih
podproblemov velikosti 1 bi tako na koncu nasli n! = n*(n-1)*...*2 -- to pa
seveda ze pri razmeroma majhnih problemih (razmeroma majhnih vrednostih n)
postane prevec, da bi si lahko privoscili resiti vse te podprobleme vsakega
posebej.  Ce se nam zgodi to, tak pristop pac ni uporaben za resevanje tega
problema (ali pa smo mi na nespameten nacin definirali podprobleme).

Kakorkoli ze -- vcasih pa se zgodi, da je teh podproblemov na videz sicer
res tako veliko, vendar se izkaze, da niso vsi razlicni.  Z malo srece je
stevilo razlicnih podproblemov reda velikosti n^nekaj (polinomsko v
odvisnosti od n), ne pa nekaj^n (eksponentno v odvisnosti od n) ali celo n!
(kar smo videli zgoraj in kar narasca se hitreje od izrazov oblike
konstanta^n).

Ce imamo to sreco, je koristno resitve podproblemov shranjevati nekam v
pomnilnik, tako da nam kasneje, ko na tak podproblem spet naletimo, ni treba
vec resevati tega podproblema (in njegovih podpodproblemov itd.), pac pa le
poberemo resitev iz tabele in gremo naprej.  Tako lahko prihranimo ogromno
casa.  (Taksni tehniki, ko si definiramo neke podprobleme in potem opazimo,
da jih sploh ni toliko razlicnih oz. da se veliko ponavljajo, pravimo
dinamicno programiranje.)

Za primer, kako tak razmislek uporabimo pri nekem konkretnem problemu, si
oglejmo resitev naloge s trikotnikom stevil.  Mislimo si, da imamo tak
trikotnik:

                      a[1,1]
                  a[2,1]   a[2,2]
             a[3,1]   a[3,2]   a[3,3]
     . . . . . . . . . . . . . . . . . . .
a[n,1] a[n,2] . . . . . . . . . . a[n,n-1] a[n,n]

In zdaj nas zanima, katera od poti, ki se zacnejo pri a[1,1] in se premikajo
vedno dol+levo ali dol+desno, nam da najvecjo vsoto.  Pot lahko v mislih
razdelimo takole: najprej naredimo prvi korak, potem pa nam preostane se
preostanek poti.  Za prvi korak sta dve moznosti.  Lahko se premaknemo
dol+levo, torej na mesto a[2,1]; v tem primeru je preostanek poti v bistvu
najboljsa pot v okviru trikotnika z vrhom pri a[2,1]:

                  a[2,1]
             a[3,1]   a[3,2]
     . . . . . . . . . . . . . . .
a[n,1] a[n,2] . . . . . .  a[n,n-1] a[n,n]

Kajti ko smo enkrat v a[2,1], se s premiki, kakrsne lahko delamo (dol+levo
ali dol+desno), ne moremo vec premakniti ven iz tega trikotnika -- torej nam
ostane le se to, da znotraj njega dosezemo cim vecjo vsoto.

Ce pa bi bil prvi korak iz a[1,1] v smeri dol+desno, bi prisli v a[2,2] in
ostali odtlej v okviru trikotnika z vrhom pri a[2,2]:

                           a[2,2]
                      a[3,2]   a[3,3]
                   . . . . . . . . . . . .
       a[n,2] . . . . . . . . . . a[n,n-1] a[n,n]


V obeh primerih smo dobili podproblem, ki je iste vrste kot prvotni problem
(iscemo najvecjo vsoto poti od vrha do dna pri nekem trikotniku stevil), le
da je malo manjsi od prvotnega (trikotnik ima n-1 vrstic namesto n vrstic).

Ko bi se lotili npr. trikotnika z vrhom v a[2,1], bi imeli za prvi korak
spet dve moznosti -- ce bi sli dol+levo, bi dobili kot podpodproblem
trikotnik z vrhom v a[3,1], ce bi sli dol+desno, pa trikotnik z vrhom v
a[3,2].

Naivna resitev te naloge bi bila torej lahko nekako taka:

  function ResiPodproblem(i, j: Integer): Integer;
  begin
    (* Iscemo najboljso resitev za trikotnik
       z vrhom v a[i, j]. *)
    (* Najprej poskrbimo za trivialne podprobleme.
       Ce je i = n, smo na zadnji vrstici celega
       trikotnika in o kaksni poti sploh ne moremo
       govoriti -- trikotnik z vrhom v a[n, j] obsega
       samo stevilo a[n, j] in nic drugega. *)
    if i = n then ResiPodproblem := a[i, j]
    else begin
      (* Moznost 1: prvi korak bo dol+levo. *)
      vsota1 := a[i, j] + ResiPodproblem(i+1, j);
      (* Moznost 2: prvi korak bo dol+desno. *)
      vsota2 := a[i, j] + ResiPodproblem(i+1, j+1);
      (* Vrnemo tisto moznost, ki nam da vecjo vsoto. *)
      if vsota1 > vsota2 then ResiPodproblem := vsota1
      else ResiPodproblem := vsota2;
    end;
  end;

Razmislimo, kaksna je casovna zahtevnost te resitve.  Kolikokrat bi se
izvedel podprobram ResiPodproblem, ce bi ga na zacetku poklicali za cel
trikotnik, torej ResiPodproblem(1, 1)?  Ce je i = n, nas podprobram le vrne
a[i, j], kar porabi recimo konstantno mnogo casa.  Ce pa je i < n, bi
dvakrat klical samega sebe, le da bi prvi parameter povecal za 1.  Torej, ce
je i = n-1, bi dvakrat klical samega sebe z i=n.  Ce je i=n-2, bi dvakrat
klical samega sebe z i=n-1, v okviru tega pa bi potem vsakic po dvakrat
klical samega sebe z i=n, torej bi imeli skupaj ze stiri klice tega
podprograma z i=n.  Podobno, ce bi zaceli pri i=n-3, bi se izvedla dva klica
z i=n-2, v okviru tega pa zato skupno stirje klici z i=n-1 in osem klicev z
i=n.  Mi pa zacnemo pri i=1; zato se izvedeta nato dva klica z i=2, stirje
klici z i=3, osem klicev z i=4, sestnajst klicev z i=5, itd., itd. in na
koncu ocitno kar 2^(n-1) klicev z i=n.  Vsega skupaj bi se nas podprogram
klical 1+2+4+8+...+2^(n-1) = ((2^n)-1)-krat.  To pa je ze pri razmeroma
majhnih n-jih kar veliko.

Toda poglejmo poblize, kaksni bi bili videti ti klici pri recimo trikotniku
s stirimi vrsticami:

  ResiPodproblem(1, 1)
    ResiPodproblem(2, 1)
      ResiPodproblem(3, 1)
        ResiPodproblem(4, 1)
        ResiPodproblem(4, 2)
      ResiPodproblem(3, 2)
        ResiPodproblem(4, 2)
        ResiPodproblem(4, 3)
    ResiPodproblem(2, 2)
      ResiPodproblem(3, 2)
        ResiPodproblem(4, 2)
        ResiPodproblem(4, 3)
      ResiPodproblem(3, 3)
        ResiPodproblem(4, 3)
        ResiPodproblem(4, 4)

To je 15 klicev, tako kot smo izracunali zgoraj.  Toda vidimo lahko, da se
nam nekateri klici ponavljajo po veckrat -- ResiPodproblem(4, 2) se na
primer klice kar trikrat.  Ker pa se nam stevila v trikotniku med izvajanjem
nic ne spreminjajo, bi seveda vsi ti klici dobili isti rezultat, torej je
cisto nepotrebno, da ga racunamo po veckrat.

Do istega razmisleka bi prisli lahko tudi takole: ResiPodproblem(i, j) naj
bi izracunal rezultat za trikotnik, ki se zacne pri a[i, j].  Toda koliko
sploh je teh trikotnikov?  Eden je za i=1; za i=2 sta dva; za i=3 so trije,
itd., in za i=n jih je n.  Vseh skupaj je torej 1+2+3+...+n = n*(n+1)/2, kar
je veliko manj od (2^n)-1.  Torej je neizogibno, da se velikokrat resujejo
isti podproblemi.

[Mimogrede, ce koga to zabava, je zanimivo razmisliti o tem, kolikokrat se
pri gornji naivni resitvi klice ResiPodproblem(i, j) za neka konkretna i in
j.  Videli bi, da dobimo ravno binomske koeficiente (stevila iz pascalovega
trikotnika) -- ResiPodproblem(i, j) se klice (i nad j)-krat.]

--

Kakorkoli ze, kaj lahko zdaj naredimo, da se izognemo po veckratnemu
resevanju istih podproblemov?  Obicajno naredijo nekaj od naslednjega
dvojega (seveda sta si oba pristopa zelo podobna): (a) svoj podprogram za
resevanje podproblemov pokrpamo tako, da si na koncu zapomni resitev
podproblema (npr. v neki tabeli), na zacetku pa preveri, ce ima to resitev
ze shranjeno od prej in je v tem primeru sploh ne racuna se enkrat; (b)
premislimo, v kaksnem vrstnem redu moramo resevati podprobleme, da bomo imel
i resitve manjsih podproblemov vedno ze pri roki, ko bomo resevali vecje
podprobleme.

Resitev tipa (b) je obicajno malenkost bolj ucinkovita, ker jo lahko pogosto
zapisemo z nekaj for zankami namesto z nekim podprogramom, ki bi klical
samega sebe.  Je pa (a) v nekem smislu preprostejsa, sploh ce nam je
dinamicno programiranje nekaj novega.  Zato si najprej oglejmo resitev tipa
(a).

(* b[i, j] naj bo najboljsa vsota, ki se jo da dobiti,
   ce zacnemo na mestu a[i, j].  Z drugimi besedami:
   to je resitev podproblema za trikotnik z vrhom
   v a[i, j]. *)
var b: array[1..n, 1..n] of Integer;

function ResiPodproblem(i, j: Integer): Integer;
var Resitev, Vsota1, Vsota2: Integer;
begin
  if b[i, j] <> NekaNeveljavnaVrednost then
    (* Ta podproblem smo resili ze nekoc prej
       in zdaj uporabimo resitev, ki smo si jo
       takrat zapomnili. *)
    ResiPodproblem := b[i, j]
  else
    begin
    (* Resimo podproblem -- cisto tako kot pri
       zgornji naivni resitvi. *)
    if i = n then Resitev := a[i, j]
    else
      begin
      Vsota1 := a[i, j] + ResiPodproblem(i+1, j);
      Vsota2 := a[i, j] + ResiPodproblem(i+1, j+1);
      if Vsota1 > Vsota2 then Resitev := Vsota1
      else Resitev := Vsota2;
      end;
    ResiPodproblem := Resitev;
    (* Na koncu si se zapomnimo resitev tega
       podproblema, da nam ga kasneje ne bo treba
       resevati se enkrat. *)
    b[i, j] := Resitev;
    end;
end;

procedure ResiProblem;
begin
  Preberi vhodne podatke;
  (* Preden prvic poklicemo ResiPodproblem, moramo
     inicializirati tabelo b. *)
  for i := 1 to n do
    for j := 1 to i do
      b[i, j] := NekaNeveljavnaVrednost;
  WriteLn('Najboljsa vsota je: ', ResiPodproblem(1, 1));
end;

Vprasanje je mogoce le se, kako v tabeli b oznaciti, da nekega podproblema
nismo se nikoli resili.  V tej resitvi je to vprasanje nakazano s konstanto
NekaNeveljavnaVrednost.  Pri nasi nalogi bi to bilo lahko npr. kaksno
negativno stevilo, ker negativna stevila v tabeli a in zato tudi v vsotah ne
morejo nastopati.  V skrajni sili pa bi lahko definirali tudi posebno
tabelo, npr. SmoZeResevaliTaPodproblem[1..n, 1..n] of Boolean, v kateri bi
na zacetku postavili vse elemente na False, nato pa bi, ko bi resili nek
podproblem, ustrezni element postavili na True.

Koristno je tudi razmisliti o casovni zahtevnosti te nove resitve.
Kolikokrat se klice ResiPodproblem(i, j)?  No, kliceta ga lahko le
ResiPodproblem(i-1, j-1) in ResiPodproblem(i-1, j); in vsak od njiju ga bo
poklical le enkrat, namrec ko se bo sam prvic izvajal (kajti ko se bo sam
drugic izvajal, bo imel resitev ze shranjeno in mu ne bo treba klicati
resevanja podproblemov).  Torej se ResiPodproblem(i, j) klice najvec
dvakrat.  [P.S. 1: Pri tem ima seveda le pri prvem klicu res kaj dela -- pri
drugem le pogleda v b[i, j] in takoj vrne tisto vrednost.  V praksi bi
mogoce prihranili kaj casa, ce bi ze klicatelj, preden klice
ResiPodproblem(i, j), preveril v b[i, j], ce ni ta podproblem slucajno ze
resen, in se v tem primeru izognil klicu podprogama ResiPodproblem(i, j) in
s tem povezanemu knjigovodskemu delu.]  [P.S. 2: Za j=1 in j=i se klice
ResiPodproblem(i, j) v resnici le enkrat samkrat, ker se pojavlja kot
podproblem le pri enem -- [i, 1] se pojavlja kot podproblem le pri [i-1, 1],
problem [i, i] pa se pojavlja kot podproblem le pri [i-1, i-1].]

Ker se ResiPodproblem(i, j) klice najvec dvakrat in ker je moznih parov (i,
j) le n(n+1)/2, je vseh klicev podprograma ResiPodproblem(i, j) najvec
n(n+1).  [V resnici jih je n^2 - n + 1, ce se nisem kje zmotil pri
sestevanju.]  Ce odmislimo klice podpodproblemov, ima vsako izvajanje
podprograma ResiPodproblem konstantno veliko dela, torej lahko recemo, da
narasca casovna zahtevnost nase resitve priblizno sorazmerno z n^2.

Mimogrede, tu opisanemu pristopu (a) vcasih pravijo "memoizacija" .

--

Zdaj pa si oglejmo se pristop (b), ki je malenkost elegantnejsi.  Videli
smo, da moramo, ce hocemo resiti podproblem za trikotnik z vrhom v a[i, j],
najprej resiti podproblema za trikotnika z vrhoma v a[i+1, j] in a[i+1,
j+1].  Ocitno je torej pametno najprej resiti najmanjse podprobleme in nato
od tam napredovati proti vecjim.  To nam bo zagotovilo, da bomo, ko se bomo
lotili trikotnika z vrhom a[i, i], ze imeli resitve njegovih dveh
podproblemov.  Zato nam ne bo treba preverjati, ali smo nek podproblem ze
resevali ali ne, saj bomo vedeli, da je bil ze resen.

var b: array[1..n, 1..n] of Integer;

(* Najprej resimo najmanjse podprobleme, torej
   tiste pri i = n.  Kot smo videli, predstavljajo
   ti podproblemi trikotnike z enim samim stevilom
   in najboljsa vsota je kar to stevilo samo. *)
for j := 1 to n do b[n, j] := a[n, j];

(* Zdaj pa resujmo vedno vecje podprobleme. *)
for i := n-1 downto 1 do
  for j := 1 to i do
    begin
    Vsota1 := a[i, j] + b[i+1, j];
    Vsota2 := a[i, j] + b[i+1, j+1];
    if Vsota1 > Vsota2 then b[i, j] := Vsota1
    else b[i, j] := Vsota2;
    end;

WriteLn('Najboljsa vsota je: ', b[1, 1]);

Vidimo torej, da nam ni bilo treba nic preverjati, ce smo nek podproblem ze
resili ali ne.  Vsakega podproblema smo se lotili natanko enkrat in to v
casu, ko sta bila njegova podpodproblema ze resena.

Seveda lahko stvari zdaj se malo spoliramo.  Ko resujemo trikotnike z vrhom
v vrstici i, potrebujemo kot podprobleme resitve trikotnikov z vrhovi v
vrstici i+1 -- resitve trikotnikov z vrhovi v vrsticah i+2, i+3, ..., n pa
ne potrebujemo vec.  Ko bomo resili vse v vrstici i, bomo lahko zavrgli tudi
tiste za vrstico i+1, ker jih ne bomo vec potrebovali.  Skratka, vsak
podproblem potrebuje le tista dva tik pod sabo.

var b[1..n] of Integer;

for j := 1 to n do b[j] := a[n, j];

for i := n-1 downto 1 do
  for j := 1 to i do
    begin
    (* b[j] in b[j+1] vsebujeta v tem trenutku se
       resitve trikotnikov z vrhovoma v a[i+1, j]
       in a[i+1, j+1].  Zdaj lahko pripravimo resitev
       za trikotnik z vrhom a[i, j]. *)
    Vsota1 := a[i, j] + b[j];
    Vsota2 := a[i, j] + b[j + 1];
    (* Vrednost b[j] lahko zdaj mirno povozimo, ker
       resitve trikotnika z vrhom v a[i+1, j] ne bomo
       vec potrebovali.  Zato lahko tja zdaj vpisemo
       resitev trikotnika z vrhom v a[i, j]. *)
    if Vsota1 > Vsota2 then b[j] := Vsota1
    else b[j] := Vsota2;
    end;

Lahko bi tudi packali po vhodni tabeli a, saj tudi stevila a[i, j] po
tistem, ko smo izracunali resitev za trikotnik z vrhom pri a[i, j], ne
potrebujemo vec.

(* Za podprobleme z i=n tu ni vec kaj storiti,
   ker so njihove resitve enake kar a[n, j] in
   so te vrednosti ze v ustreznih elementih
   tabele a.  Zdaj se lahko lotimo vecjih
   podproblemov. *)
for i := n-1 downto 1 do
  for j := 1 to i do
    begin
    (* a[i, j] se vsebuje prvotno stevilo a[i, j],
       v a[i+1, j] in a[i+1, j+1] pa sta zdaj ze
       celotni najboljsi vsoti za trikotnika z vrhovoma
       v [i+1, j] in [i+1, j+1]. *)
    Vsota1 := a[i, j] + a[i+1, j];
    Vsota2 := a[i, j] + a[i+1, j+1];
    if Vsota1 > Vsota2 then a[i, j] := Vsota1
    else a[i, j] := Vsota2;
    end;
WriteLn('Najboljsa resitev je: ', a[1, 1]);

Skratka, vidimo, da so lahko resitve po tem pristopu bolj elegantne kot z
memoizacijo, saj je vse opravljeno z nekaj zankami, brez klicev podprogramov
in podobnih reci.  Vendar pa smo morali bolje razumeti strukturo problema --
razumeti, v kaksnem vrstnem redu je treba resevati podprobleme.  Res je
tudi, da casovna zahtevnost tega pristopa ravno tako narasca sorazmerno z
n^2 (ker imamo dve gnezdeni zanki, stevec i gre od n-1 do 1, stevec j pa od
1 do i), torej po tej plati nismo bistveno boljsi od pristopa (a), ki smo ga
opisali zgoraj -- pristop (b) bi bil mogoce boljsi za nek konstantni faktor,
kaj dosti vec pa ne.

--

Mogoce lahko razlozim se, zakaj sem ves cas tako trdoglavo pisal stvari v
stilu

    Vsota1 := a[i, j] + b[i+1, j];
    Vsota2 := a[i, j] + b[i+1, j+1];
    if Vsota1 > Vsota2 then Rezultat := Vsota1
    else Rezultat := Vsota2;

namesto npr.

    if b[i+1, j] > b[i+1, j+1] then Rezultat := b[i+1, j]
    else Rezultat := b[i+1, j+1];
    Rezultat := Rezultat + a[i, j];

S tem sem hotel poudariti, da imamo dve alternativi -- eno predstavlja
Vsota1, drugo pa Vsota2 -- in si moramo izbrati boljso izmed njiju.  V nasem
primeru imata obe alternativi res skupni clen a[i, j], vendar v splosnem ni
nujno tako.  Nas problem bi lahko na primer definirali tako, da so stevila
obesena na premike med polji trikotnika, ne pa na polja sama.  Potem oceni
obeh alternativ ne bi imeli nicesar skupnega, ampak bi imeli nekaj takega:

  Vsota1 := NagradaZaPremik[i, j, dolInLevo] + b[i+1, j];
  Vsota2 := NagradaZaPremik[i, j, dolInDesno] + b[i+1, j+1];
  if Vsota1 > Vsota2 then Rezultat := Vsota1
  else Rezultat := Vsota2;

Pri drugih problemih seveda tudi ni nujno, da sta alternativi ravno 2.
Mogoce jih je vec -- mogoce imamo pri problemu velikosti n kar n moznosti,
kako storiti prvi korak do resitve.  Nas problem s trikotnikom pa je bil pac
tak, da sta za prvi korak vedno le dve moznosti, dol+levo in dol+desno.

--

Vcasih se lahko naloge lotimo na vec nacinov in tudi na razlicne nacine
definiramo podprobleme.  Doslej smo v nalogi s trikotniki delali s
podproblemi oblike "poisci najboljso vsoto od stevila a[i, j] do dna
trikotnika".  To je lepo, ker je podproblem v bistvu spet trikotnik, le za
eno vrstico manjsi.  Do teh podproblemov smo prisli tako, da smo si rekli:
zacnemo v vrhu trikotnika, najprej moramo storiti prvi korak (ki je lahko
dol+levo ali dol+desno), preostanek poti pa je podproblem iste oblike, a za
eno vrstico manjsi.

Lahko pa bi rekli tudi tako: na koncu je treba storiti zadnji korak, tisto
pred tem pa je nek problem priblizno iste oblike in tudi nekako manjsi.  V
tem primeru bi bili podproblemi oblike "poisci najboljso vsoto od vrha
trikotnika (torej od a[1, 1]) do stevila a[i, j]".  Zdaj nam torej vecji i
pomeni vecje podprobleme (in ne manjsih tako kot zgoraj).  Resitev nasega
prvotnega podproblema bi dobili tako, da bi vzeli najvecjo vsoto po vseh
poteh do a[n, j] (torej do dna prvotnega trikotnika).  Pri i = j = 1 pa
imamo trivialni problem -- iskanje poti od a[1, 1] do a[1, 1].  Podproblemi
zdaj niso vec iskanje najboljsih vsot po trikotnikih, ampak po raznih bolj
cudnih likih.  Na primer: za i = 4, j = 2 imamo zdaj v bistvu tak
podproblem:

           a[1,1]
       a[2,1]   a[2,2]
 a[3,1]    a[3,2]
       a[4,2]

(Saj do drugih stevil kot do tu narisanih ne moremo priti, ce zacnemo v
a[1,1] in koncamo v a[4,2].)

No, kako bi zdaj resili podproblem za neka konkretna i in j?  Zadnji korak,
ki nas je pripeljal do stevila a[i, j], je bil ali iz njegovega levega
zgornjega soseda a[i-1, j-1] ali pa iz njegovega desnega zgornjega soseda
a[i-1, j].  Seveda nekatera stevila sploh nimajo obeh zgornjih sosedov -- to
so tista na robu trikotnika.  Stevilo a[i, 1] na levem robu trikotnika ima
le desnega zgornjega soseda a[i-1, 1]; stevilo a[i, i] na desnem robu
trikotnika pa ima le levega zgornjega soseda a[i-1, i-1].  V teh primerih je
pac mozen zadnji korak le en sam in je temu primerno tudi podpodproblem en
sam.

Kakorkoli ze, zdaj, ko nam je jasno, kako lahko tak podproblem resimo s
pomocjo resitev manjsih podproblemov, lahko stvari zastavimo tako kot prej:

  function ResiPodproblem2(i, j: Integer): Integer;
  var Vsota1, Vsota2: Integer;
  begin
    if i = 1 and j = 1 then
      (* Trivialni podproblem: pot od [1, 1] do [1, 1]. *)
      ResiPodproblem2 := a[1, 1]
    else if j = 1 then
      (* Imamo le desnega zgornjega soseda. *)
      ResiPodproblem2 := ResiPodproblem2(i-1, j) + a[i, j]
    else if j = i then
      (* Imamo le levega zgornjega soseda. *)
      ResiPodproblem2 := ResiPodproblem2(i-1, j-1) + a[i, j]
    else
      begin
      Vsota1 := ResiPodproblem2(i-1, j) + a[i, j];
      Vsota2 := ResiPodproblem2(i-1, j-1) + a[i, j];
      if Vsota1 > Vsota2 then ResiPodproblem2 := Vsota1
      elsa ResiPodproblema2 := Vsota2;
      end;
  end;

Se ena tehnika, ki lahko pride prav, da se tole zgoraj malo poenostavi, je
zamisel o "strazarjih".  To v splosnem deluje nekako takole: da nam ni treba
preverjati, kdaj smo prisli do roba tabele (ali seznama ali cesa), postavimo
tja taksne vrednosti, zaradi katerih se bo zdelo, da je tam nekaj zelo
neobetavnega, tako da tistih vrednosti ne bomo nikoli zares uporabili.  V
nasem primeru bi to storili tako, da bi ResiPodproblem2 deloval tudi za
moznosti, ko je j = 0 ali pa j = i+1, vendar bi v tem primeru vrnil nekaj,
kar je gotovo slabse od vsake resnicne vsote v trikotniku.  Ker imamo mi v
trikotniku le nenegativna stevila, bi lahko ResiPodproblem2 v tem primeru na
primer vrnil kaj negativnega.  Prednost bi bila v tem, da nam ne bi bilo
treba vec preverjati, ce ima trenutni element mogoce le zgornjega desnega
ali pa le zgornjega levega soseda -- lahko bi se delali, kot da imamo vedno
oba zgornja soseda, ResiPodproblem2 pa bi z vracanjem negativnih stevil v
tistih primerih, ko takega soseda v resnici ni, ze poskrbel, da se nikoli ne
bi odlocili za taksno neveljavno pot.

Kljub temu se lahko vprasamo, v cem je sploh smisel tega, da smo sli obracat
definicijo podproblemov in tako prisli do necesa, kar je mogoce videti bolj
komplicirano od prvotne resitve.  Odgovor je v tem, da zdaj obdelujemo
prvotni trikotnik strogo od zgoraj navzdol, ne pa od spodaj navzgor.  Ker ga
na vhodu dobimo zapisanega od zgoraj navzdol, to zdaj pomeni, da si ni vec
treba zapomniti celega trikotnika, pac pa le trenutno vrstico (in resitve
podproblemov za poti, ki se koncajo v prejsnji vrstici -- seveda lahko tudi
te zivijo v isti tabeli, kjer je tudi trenutna vrstica trikotnika).  Torej:

var b[0..n+1] of Integer;

Read(b[1]);
b[0] := -123; b[2] := -123; (* strazarja *)
for i := 1 to n do
  begin
  bb := b[0];
  for j := 1 to n do
    begin
    (* V b[j-1] je zdaj ze resitev podproblema [i, j-1].
       Pac pa imamo v bb resitev podproblema [i-1, j-1].
       Resitev podproblema [i-1, j] je se vedno v b[j]. *)
    (* Preberimo stevilo a[i, j]. *)
    Read(aa);
    (* Preizkusimo obe alternativi. *)
    Vsota1 := bb + aa;
    Vsota2 := b[j] + aa;
    (* Zapomnimo si resitev podproblema [i-1, j],
       ker jo bomo se potrebovali v naslednji iteraciji
       te zanke, ko bomo resevali podproblem [i, j+1]. *)
    bb := b[j];
    (* Vpisimo v b[j] resitev podproblema [i, j].
    if Vsota1 > Vsota2 then b[j] := Vsota1
    else b[j] := Vsota2;
    end;
  end;
(* Zdaj bi morali med vrednostmi b[1], ..., b[n]
   poiskati najvecjo -- to je najboljsa vsota za
   celotni trikotnik. *)

Ta resitev torej po casovni zahtevnosti nic ne zaostaja za tisto, ki je sla
po trikotniku od spodaj navzdol, je pa bolj varcna s pomnilnikom.  Se pa
seveda ne zgodi pri vsakem problemu, da bi lahko tako enostavno prihranili
toliko pomnilnika -- pa tudi ni nujno, da je taksno varcevanje s pomnilnikom
sploh potrebno (obicajno verjetno ni).

--

Se en nasvet glede dinamicnega programiranja: videli smo, da so lahko
podproblemi v nekem smislu splosnejsi od prvotnega podproblema.  Na primer:
problem je bil iskati vsoto za cel trikotnik; podproblemi pa so vsote od
stevila a[i, j] do dna trikotnika.  Torej so podproblemi v nekem smislu
splosnejsi in parametrizirani z vrednostma i in j.  To je vse lepo in prav
in je tudi potrebno, ker taksno posplositev potrebujemo, da lahko
podprobleme resujemo enega z drugim (vecjega resimo s pomocjo resitev
manjsih podproblemov).  Paziti pa moramo, da ne bi stvari prevec posplosili
in tako dobili prevec podproblemov -- ce bi vzeli take podprobleme, da bi
jih bilo na primer n^3 namesto n^2, bi nas program verjetno porabil vec
casa, pa tudi vec pomnilnika.  Tako se lahko zapletemo v tezave s kaksnimi
casovnimi in/ali prostorskimi omejitvami.

--

Vcasih moramo ne le povedati, kako dobra je najboljsa resitev, ampak to
najboljso resitev tudi izpisati.  Na primer: ne le povedati, koliksna je
najboljsa vsota v trikotniku, ampak tudi, po kateri poti smo jo dobili.  To
obicajno lahko dosezemo tako, da si, ko resujemo nek podproblem, zapomnimo
ne le to, kaksna je najboljsa resitev tega podproblema, ampak tudi to, po
kateri od moznih alternativ smo jo dobili.  Ce imamo te podatke, jim lahko
sledimo in tako rekonstruiramo celo resitev korak za korakom.  Pac pa to
obicajno pomeni, da moramo ves cas hraniti ocene resitev vseh podproblemov
in si tezje privoscimo, da bi jih sproti brisali, kot smo poceli zgoraj.

Primer za nas trikotnik:

var b: array[1..n, 1..n] of Integer;
    c: array[1..n-1, 1..n] of (DolLevo, DolDesno);

for j := 1 to n do b[n, j] := a[n, j];

for i := n-1 downto 1 do
  for j := 1 to i do
    begin
    Vsota1 := a[i, j] + b[i+1, j];
    Vsota2 := a[i, j] + b[i+1, j+1];
    if Vsota1 > Vsota2 then
      begin b[i, j] := Vsota1; c[i, j] := DolLevo; end
    else
      begin b[i, j] := Vsota2; c[i, j] := DolDesno; end;
    end;

(* Rekonstruirajmo celo resitev. *)
i := 1; j := 1; WriteLn(i, ' ', j);
while i < n do begin
  if c[i, j] = DolLevo then
    begin i := i + 1; j := j; end
  else (* c[i, j] = DolDesno *)
    begin i := i + 1; j := j+1; end;
  WriteLn(i, ' ', j);
  end;

Seveda bi se dalo tole zadnjo zanko napisati za odtenek bolj elegantno.  A
poudarek je tu na tem, da moramo iz vrednosti c[i, j] ugotoviti, s kaksnim
podproblemom se resitev nadaljuje (v nasem primeru: ce je c[i, j] = DolLevo,
se nadaljuje s podproblemom [i+1, j], sicer pa z [i+1, j+1]).

--

Bralcem, ki so se pretolkli skozi vse te moje dolge marnje, se zahvaljujem
za pozornost in upam, da jim je (oz. bo) vse skupaj kaj koristilo.  Dejstvo
je, da pride dinamicno programiranje zelo prav pri mnogih nalogah (v
resnicnem svetu pa mogoce malo manj, obcasno pa vendarle).  Temu mailu
prilagam se nekaj nalog, ki se jih da resiti z dinamicnim programiranjem.
Nekatere so s starih olimpijad, nekatere pa z ACMovih tekmovanj.  Vsekakor
vas spodbujam, da se poskusate vsaj nekaterih od njih lotiti, ce boste imeli
z njimi kaksne probleme ali vprasanja, pa seveda vprasajte.

Seveda je dinamicno programiranje tudi zelo primerna tehnika za nalogo z
mnozenjem matrik, ki sem jo poslal pred nekaj dnevi.  Je pa v primerjavi s
trikotnikom razlika ta, da pri nalogi z mnozenjem matrik pri problemu
velikosti n mozni alternativi za prvi korak v resitvi nista dve, ampak jih
je v bistvu kar n-1.

LP, Janez